/*
 * Copyright 2004-2011 H2 Group. Multiple-Licensed under the H2 License,
 * Version 1.0, and under the Eclipse Public License, Version 1.0
 * (http://h2database.com/html/license.html).
 * Initial Developer: H2 Group
 */
package org.h2.server.web;

import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.ServerSocket;
import java.net.Socket;
import java.sql.Connection;
import java.sql.SQLException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Locale;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Properties;
import java.util.Set;
import java.util.TimeZone;

import org.h2.constant.SysProperties;
import org.h2.engine.Constants;
import org.h2.message.TraceSystem;
import org.h2.server.Service;
import org.h2.server.ShutdownHandler;
import org.h2.store.fs.FileUtils;
import org.h2.util.IOUtils;
import org.h2.util.JdbcUtils;
import org.h2.util.MathUtils;
import org.h2.util.NetUtils;
import org.h2.util.New;
import org.h2.util.SortedProperties;
import org.h2.util.StringUtils;
import org.h2.util.Tool;
import org.h2.util.Utils;

/**
 * The web server is a simple standalone HTTP server that implements the H2
 * Console application. It is not optimized for performance.
 */
public class WebServer implements Service {

	static final String TRANSFER = "transfer";

	static final String[][] LANGUAGES = { { "cs", "\u010ce\u0161tina" }, { "de", "Deutsch" }, { "en", "English" },
			{ "es", "Espa\u00f1ol" }, { "fr", "Fran\u00e7ais" }, { "hu", "Magyar" }, { "in", "Indonesia" },
			{ "it", "Italiano" }, { "ja", "\u65e5\u672c\u8a9e" }, { "nl", "Nederlands" }, { "pl", "Polski" },
			{ "pt_BR", "Portugu\u00eas (Brasil)" }, { "pt_PT", "Portugu\u00eas (Europeu)" },
			{ "ru", "\u0440\u0443\u0441\u0441\u043a\u0438\u0439" }, { "sk", "Slovensky" },
			{ "tr", "T\u00fcrk\u00e7e" }, { "uk", "\u0423\u043A\u0440\u0430\u0457\u043D\u0441\u044C\u043A\u0430" },
			{ "zh_CN", "\u4e2d\u6587 (\u7b80\u4f53)" }, { "zh_TW", "\u4e2d\u6587 (\u7e41\u9ad4)" }, };

	private static final String DEFAULT_LANGUAGE = "en";

	private static final String[] GENERIC = {
			"Generic JNDI Data Source|javax.naming.InitialContext|java:comp/env/jdbc/Test|sa",
			"Generic Firebird Server|org.firebirdsql.jdbc.FBDriver|jdbc:firebirdsql:localhost:c:/temp/firebird/test|sysdba",
			"Generic SQLite|org.sqlite.JDBC|jdbc:sqlite:test|sa",
			"Generic DB2|COM.ibm.db2.jdbc.net.DB2Driver|jdbc:db2://localhost/test|",
			"Generic Oracle|oracle.jdbc.driver.OracleDriver|jdbc:oracle:thin:@localhost:1521:XE|sa",
			"Generic MS SQL Server 2000|com.microsoft.jdbc.sqlserver.SQLServerDriver|"
					+ "jdbc:microsoft:sqlserver://localhost:1433;DatabaseName=sqlexpress|sa",
			"Generic MS SQL Server 2005|com.microsoft.sqlserver.jdbc.SQLServerDriver|"
					+ "jdbc:sqlserver://localhost;DatabaseName=test|sa",
			"Generic PostgreSQL|org.postgresql.Driver|jdbc:postgresql:test|",
			"Generic MySQL|com.mysql.jdbc.Driver|jdbc:mysql://localhost:3306/test|",
			"Generic HSQLDB|org.hsqldb.jdbcDriver|jdbc:hsqldb:test;hsqldb.default_table_type=cached|sa",
			"Generic Derby (Server)|org.apache.derby.jdbc.ClientDriver|jdbc:derby://localhost:1527/test;create=true|sa",
			"Generic Derby (Embedded)|org.apache.derby.jdbc.EmbeddedDriver|jdbc:derby:test;create=true|sa",
			"Generic H2 (Server)|org.h2.Driver|jdbc:h2:tcp://localhost/~/test|sa",
			// this will be listed on top for new installations
			"Generic H2 (Embedded)|org.h2.Driver|jdbc:h2:~/test|sa", };

	private static int ticker;

	/**
	 * The session timeout is 30 min.
	 */
	private static final long SESSION_TIMEOUT = 30 * 60 * 1000;

	//    public static void main(String... args) throws IOException {
	//        String s = IOUtils.readStringAndClose(new java.io.FileReader(
	//                // "src/main/org/h2/server/web/res/_text_cs.prop"), -1);
	//                "src/main/org/h2/res/_messages_cs.prop"), -1);
	//        System.out.println(StringUtils.javaEncode("..."));
	//        String[] list = Locale.getISOLanguages();
	//        for (int i = 0; i < list.length; i++) {
	//            System.out.print(list[i] + " ");
	//        }
	//        System.out.println();
	//        String l = "de";
	//        String lang = new java.util.Locale(l).
	//            getDisplayLanguage(new java.util.Locale(l));
	//        System.out.println(new java.util.Locale(l).getDisplayLanguage());
	//        System.out.println(lang);
	//        java.util.Locale.CHINESE.getDisplayLanguage(java.util.Locale.CHINESE);
	//        for (int i = 0; i < lang.length(); i++) {
	//            System.out.println(Integer.toHexString(lang.charAt(i)) + " ");
	//        }
	//    }

	// private URLClassLoader urlClassLoader;
	private int port;
	private boolean allowOthers;
	private boolean isDaemon;
	private Set<WebThread> running = Collections.synchronizedSet(new HashSet<WebThread>());
	private boolean ssl;
	private HashMap<String, ConnectionInfo> connInfoMap = New.hashMap();

	private long lastTimeoutCheck;
	private HashMap<String, WebSession> sessions = New.hashMap();
	private HashSet<String> languages = New.hashSet();
	private String startDateTime;
	private ServerSocket serverSocket;
	private String url;
	private ShutdownHandler shutdownHandler;
	private Thread listenerThread;
	private boolean ifExists;
	private boolean trace;
	private TranslateThread translateThread;
	private boolean allowChunked = true;
	private String serverPropertiesDir = Constants.SERVER_PROPERTIES_DIR;

	/**
	 * Read the given file from the file system or from the resources.
	 *
	 * @param file the file name
	 * @return the data
	 */
	byte[] getFile(String file) throws IOException {
		trace("getFile <" + file + ">");
		if (file.startsWith(TRANSFER + "/") && new File(TRANSFER).exists()) {
			file = file.substring(TRANSFER.length() + 1);
			if (!isSimpleName(file)) {
				return null;
			}
			File f = new File(TRANSFER, file);
			if (!f.exists()) {
				return null;
			}
			return IOUtils.readBytesAndClose(new FileInputStream(f), -1);
		}//sss 经典的 h2服务器寻找web资源路径常量
		byte[] data = Utils.getResource("/org/h2/server/web/res/" + file);
		if (data == null) {
			trace(" null");
		} else {
			trace(" size=" + data.length);
		}
		return data;
	}

	/**
	 * Check if this is a simple name (only contains '.', '-', '_', letters, or
	 * digits).
	 *
	 * @param s the string
	 * @return true if it's a simple name
	 */
	static boolean isSimpleName(String s) {
		for (char c : s.toCharArray()) {
			if (c != '.' && c != '_' && c != '-' && !Character.isLetterOrDigit(c)) {
				return false;
			}
		}
		return true;
	}

	/**
	 * Remove this web thread from the set of running threads.
	 *
	 * @param t the thread to remove
	 */
	synchronized void remove(WebThread t) {
		running.remove(t);
	}

	private static String generateSessionId() {
		byte[] buff = MathUtils.secureRandomBytes(16);
		return StringUtils.convertBytesToHex(buff);
	}

	/**
	 * Get the web session object for the given session id.
	 *
	 * @param sessionId the session id
	 * @return the web session or null
	 */
	WebSession getSession(String sessionId) {
		long now = System.currentTimeMillis();
		if (lastTimeoutCheck + SESSION_TIMEOUT < now) {
			for (String id : New.arrayList(sessions.keySet())) {
				WebSession session = sessions.get(id);
				Long last = (Long) session.get("lastAccess");
				if (last != null && last.longValue() + SESSION_TIMEOUT < now) {
					trace("timeout for " + id);
					sessions.remove(id);
				}
			}
			lastTimeoutCheck = now;
		}
		WebSession session = sessions.get(sessionId);
		if (session != null) {
			session.lastAccess = System.currentTimeMillis();
		}
		return session;
	}

	/**
	 * Create a new web session id and object.
	 *
	 * @param hostAddr the host address
	 * @return the web session object
	 */
	WebSession createNewSession(String hostAddr) {
		String newId;
		do {
			newId = generateSessionId();
		} while (sessions.get(newId) != null);
		WebSession session = new WebSession(this);
		session.put("sessionId", newId);
		session.put("ip", hostAddr);
		session.put("language", DEFAULT_LANGUAGE);
		session.put("frame-border", "0");
		session.put("frameset-border", "4");
		sessions.put(newId, session);
		// always read the english translation,
		// so that untranslated text appears at least in english
		readTranslations(session, DEFAULT_LANGUAGE);
		return getSession(newId);
	}

	String getStartDateTime() {
		if (startDateTime == null) {
			SimpleDateFormat format = new SimpleDateFormat("EEE, d MMM yyyy HH:mm:ss z", new Locale("en", ""));
			format.setTimeZone(TimeZone.getTimeZone("GMT"));
			startDateTime = format.format(new Date());
		}
		return startDateTime;
	}

	public void init(String... args) {
		// set the serverPropertiesDir, because it's used in loadProperties()
		for (int i = 0; args != null && i < args.length; i++) {
			if ("-properties".equals(args[i])) {
				serverPropertiesDir = args[++i];
			}
		}
		Properties prop = loadProperties();
		port = SortedProperties.getIntProperty(prop, "webPort", Constants.DEFAULT_HTTP_PORT);
		ssl = SortedProperties.getBooleanProperty(prop, "webSSL", false);
		allowOthers = SortedProperties.getBooleanProperty(prop, "webAllowOthers", false);
		for (int i = 0; args != null && i < args.length; i++) {
			String a = args[i];
			if (Tool.isOption(a, "-webPort")) {
				port = Integer.decode(args[++i]);
			} else if (Tool.isOption(a, "-webSSL")) {
				ssl = true;
			} else if (Tool.isOption(a, "-webAllowOthers")) {
				allowOthers = true;
			} else if (Tool.isOption(a, "-webDaemon")) {
				isDaemon = true;
			} else if (Tool.isOption(a, "-baseDir")) {
				String baseDir = args[++i];
				SysProperties.setBaseDir(baseDir);
			} else if (Tool.isOption(a, "-ifExists")) {
				ifExists = true;
			} else if (Tool.isOption(a, "-properties")) {
				// already set
				i++;
			} else if (Tool.isOption(a, "-trace")) {
				trace = true;
			}
		}
		//            if(driverList != null) {
		//                try {
		//                    String[] drivers =
		//                        StringUtils.arraySplit(driverList, ',', false);
		//                    URL[] urls = new URL[drivers.length];
		//                    for(int i=0; i<drivers.length; i++) {
		//                        urls[i] = new URL(drivers[i]);
		//                    }
		//                    urlClassLoader = URLClassLoader.newInstance(urls);
		//                } catch (MalformedURLException e) {
		//                    TraceSystem.traceThrowable(e);
		//                }
		//            }
		for (String[] lang : LANGUAGES) {
			languages.add(lang[0]);
		}
		updateURL();
	}

	public String getURL() {
		updateURL();
		return url;
	}

	private void updateURL() {
		try {
			url = (ssl ? "https" : "http") + "://" + NetUtils.getLocalAddress() + ":" + port;
		} catch (NoClassDefFoundError e) {
			// Google App Engine does not allow java.net.InetAddress
		}
	}

	public void start() {
		serverSocket = NetUtils.createServerSocket(port, ssl);
		port = serverSocket.getLocalPort();
		updateURL();
	}

	public void listen() {
		this.listenerThread = Thread.currentThread();
		try {
			while (serverSocket != null) {
				Socket s = serverSocket.accept();
				WebThread c = new WebThread(s, this);
				running.add(c);
				c.start();
			}
		} catch (Exception e) {
			trace(e.toString());
		}
	}

	public boolean isRunning(boolean traceError) {
		if (serverSocket == null) {
			return false;
		}
		try {
			Socket s = NetUtils.createLoopbackSocket(port, ssl);
			s.close();
			return true;
		} catch (Exception e) {
			if (traceError) {
				traceError(e);
			}
			return false;
		}
	}

	public boolean isStopped() {
		return serverSocket == null;
	}

	public void stop() {
		if (serverSocket != null) {
			try {
				serverSocket.close();
			} catch (IOException e) {
				traceError(e);
			}
			serverSocket = null;
		}
		if (listenerThread != null) {
			try {
				listenerThread.join(1000);
			} catch (InterruptedException e) {
				TraceSystem.traceThrowable(e);
			}
		}
		// TODO server: using a boolean 'now' argument? a timeout?
		for (WebSession session : New.arrayList(sessions.values())) {
			session.close();
		}
		for (WebThread c : New.arrayList(running)) {
			try {
				c.stopNow();
				c.join(100);
			} catch (Exception e) {
				traceError(e);
			}
		}
	}

	/**
	 * Write trace information if trace is enabled.
	 *
	 * @param s the message to write
	 */
	void trace(String s) {
		if (trace) {
			System.out.println(s);
		}
	}

	/**
	 * Write the stack trace if trace is enabled.
	 *
	 * @param e the exception
	 */
	void traceError(Throwable e) {
		if (trace) {
			e.printStackTrace();
		}
	}

	/**
	 * Check if this language is supported / translated.
	 *
	 * @param language the language
	 * @return true if a translation is available
	 */
	boolean supportsLanguage(String language) {
		return languages.contains(language);
	}

	/**
	 * Read the translation for this language and save them in the 'text'
	 * property of this session.
	 *
	 * @param session the session
	 * @param language the language
	 */
	void readTranslations(WebSession session, String language) {
		Properties text = new Properties();
		try {
			trace("translation: " + language);
			byte[] trans = getFile("_text_" + language + ".prop");
			trace("  " + new String(trans));
			text = SortedProperties.fromLines(new String(trans, "UTF-8"));
			// remove starting # (if not translated yet)
			for (Entry<Object, Object> entry : text.entrySet()) {
				String value = (String) entry.getValue();
				if (value.startsWith("#")) {
					entry.setValue(value.substring(1));
				}
			}
		} catch (IOException e) {
			TraceSystem.traceThrowable(e);
		}
		session.put("text", new HashMap<Object, Object>(text));
	}

	ArrayList<HashMap<String, Object>> getSessions() {
		ArrayList<HashMap<String, Object>> list = New.arrayList();
		for (WebSession s : sessions.values()) {
			list.add(s.getInfo());
		}
		return list;
	}

	public String getType() {
		return "Web Console";
	}

	public String getName() {
		return "H2 Console Server";
	}

	void setAllowOthers(boolean b) {
		allowOthers = b;
	}

	public boolean getAllowOthers() {
		return allowOthers;
	}

	void setSSL(boolean b) {
		ssl = b;
	}

	void setPort(int port) {
		this.port = port;
	}

	boolean getSSL() {
		return ssl;
	}

	public int getPort() {
		return port;
	}

	/**
	 * Get the connection information for this setting.
	 *
	 * @param name the setting name
	 * @return the connection information
	 */
	ConnectionInfo getSetting(String name) {
		return connInfoMap.get(name);
	}

	/**
	 * Update a connection information setting.
	 *
	 * @param info the connection information
	 */
	void updateSetting(ConnectionInfo info) {
		connInfoMap.put(info.name, info);
		info.lastAccess = ticker++;
	}

	/**
	 * Remove a connection information setting from the list
	 *
	 * @param name the setting to remove
	 */
	void removeSetting(String name) {
		connInfoMap.remove(name);
	}

	private Properties loadProperties() {
		try {
			if ("null".equals(serverPropertiesDir)) {
				return new Properties();
			}
			return SortedProperties.loadProperties(serverPropertiesDir + "/" + Constants.SERVER_PROPERTIES_NAME);
		} catch (Exception e) {
			TraceSystem.traceThrowable(e);
			return new Properties();
		}
	}

	/**
	 * Get the list of connection information setting names.
	 *
	 * @return the connection info names
	 */
	String[] getSettingNames() {
		ArrayList<ConnectionInfo> list = getSettings();
		String[] names = new String[list.size()];
		for (int i = 0; i < list.size(); i++) {
			names[i] = list.get(i).name;
		}
		return names;
	}

	/**
	 * Get the list of connection info objects.
	 *
	 * @return the list
	 */
	synchronized ArrayList<ConnectionInfo> getSettings() {
		ArrayList<ConnectionInfo> settings = New.arrayList();
		if (connInfoMap.size() == 0) {
			Properties prop = loadProperties();
			if (prop.size() == 0) {
				for (String gen : GENERIC) {
					ConnectionInfo info = new ConnectionInfo(gen);
					settings.add(info);
					updateSetting(info);
				}
			} else {
				for (int i = 0;; i++) {
					String data = prop.getProperty(String.valueOf(i));
					if (data == null) {
						break;
					}
					ConnectionInfo info = new ConnectionInfo(data);
					settings.add(info);
					updateSetting(info);
				}
			}
		} else {
			settings.addAll(connInfoMap.values());
		}
		Collections.sort(settings);
		return settings;
	}

	/**
	 * Save the settings to the properties file.
	 *
	 * @param prop null or the properties webPort, webAllowOthers, and webSSL
	 */
	synchronized void saveProperties(Properties prop) {
		try {
			if (prop == null) {
				Properties old = loadProperties();
				prop = new SortedProperties();
				prop.setProperty("webPort", "" + SortedProperties.getIntProperty(old, "webPort", port));
				prop.setProperty("webAllowOthers",
						"" + SortedProperties.getBooleanProperty(old, "webAllowOthers", allowOthers));
				prop.setProperty("webSSL", "" + SortedProperties.getBooleanProperty(old, "webSSL", ssl));
			}
			ArrayList<ConnectionInfo> settings = getSettings();
			int len = settings.size();
			for (int i = 0; i < len; i++) {
				ConnectionInfo info = settings.get(i);
				if (info != null) {
					prop.setProperty(String.valueOf(len - i - 1), info.getString());
				}
			}
			if (!"null".equals(serverPropertiesDir)) {
				OutputStream out = FileUtils.newOutputStream(serverPropertiesDir + "/"
						+ Constants.SERVER_PROPERTIES_NAME, false);
				prop.store(out, "H2 Server Properties");
				out.close();
			}
		} catch (Exception e) {
			TraceSystem.traceThrowable(e);
		}
	}

	/**
	 * Open a database connection.
	 *
	 * @param driver the driver class name
	 * @param databaseUrl the database URL
	 * @param user the user name
	 * @param password the password
	 * @return the database connection
	 */
	Connection getConnection(String driver, String databaseUrl, String user, String password) throws SQLException {
		driver = driver.trim();
		databaseUrl = databaseUrl.trim();
		org.h2.Driver.load();
		Properties p = new Properties();
		p.setProperty("user", user.trim());
		// do not trim the password, otherwise an
		// encrypted H2 database with empty user password doesn't work
		p.setProperty("password", password);
		if (databaseUrl.startsWith("jdbc:h2:")) {
			if (ifExists) {
				databaseUrl += ";IFEXISTS=TRUE";
			}
			// PostgreSQL would throw a NullPointerException
			// if it is loaded before the H2 driver
			// because it can't deal with non-String objects in the connection Properties
			return org.h2.Driver.load().connect(databaseUrl, p);
		}
		//            try {
		//                Driver dr = (Driver) urlClassLoader.
		//                        loadClass(driver).newInstance();
		//                return dr.connect(url, p);
		//            } catch(ClassNotFoundException e2) {
		//                throw e2;
		//            }
		return JdbcUtils.getConnection(driver, databaseUrl, p);
	}

	/**
	 * Shut down the web server.
	 */
	void shutdown() {
		if (shutdownHandler != null) {
			shutdownHandler.shutdown();
		}
	}

	public void setShutdownHandler(ShutdownHandler shutdownHandler) {
		this.shutdownHandler = shutdownHandler;
	}

	/**
	 * Create a session with a given connection.
	 *
	 * @param conn the connection
	 * @return the URL of the web site to access this connection
	 */
	public String addSession(Connection conn) throws SQLException {
		WebSession session = createNewSession("local");
		session.setShutdownServerOnDisconnect();
		session.setConnection(conn);
		session.put("url", conn.getMetaData().getURL());
		String s = (String) session.get("sessionId");
		return url + "/frame.jsp?jsessionid=" + s;
	}

	/**
	 * The translate thread reads and writes the file translation.properties
	 * once a second.
	 */
	private class TranslateThread extends Thread {

		private final File file = new File("translation.properties");
		private final Map<Object, Object> translation;
		private volatile boolean stopNow;

		TranslateThread(Map<Object, Object> translation) {
			this.translation = translation;
		}

		public String getFileName() {
			return file.getAbsolutePath();
		}

		public void stopNow() {
			this.stopNow = true;
			try {
				join();
			} catch (InterruptedException e) {
				// ignore
			}
		}

		public void run() {
			while (!stopNow) {
				try {
					SortedProperties sp = new SortedProperties();
					if (file.exists()) {
						InputStream in = FileUtils.newInputStream(file.getName());
						sp.load(in);
						translation.putAll(sp);
					} else {
						OutputStream out = FileUtils.newOutputStream(file.getName(), false);
						sp.putAll(translation);
						sp.store(out, "Translation");
					}
					Thread.sleep(1000);
				} catch (Exception e) {
					traceError(e);
				}
			}
		}

	}

	/**
	 * Start the translation thread that reads the file once a second.
	 *
	 * @param translation the translation map
	 * @return the name of the file to translate
	 */
	String startTranslate(Map<Object, Object> translation) {
		if (translateThread != null) {
			translateThread.stopNow();
		}
		translateThread = new TranslateThread(translation);
		translateThread.setDaemon(true);
		translateThread.start();
		return translateThread.getFileName();
	}

	public boolean isDaemon() {
		return isDaemon;
	}

	void setAllowChunked(boolean allowChunked) {
		this.allowChunked = allowChunked;
	}

	boolean getAllowChunked() {
		return allowChunked;
	}

}
